Skip to content

Add list_transform - #10503

Merged
mhk197 merged 7 commits into
developfrom
mk/list-transform-v3
Oct 27, 2026
Merged

mhk197 merged 7 commits into
developfrom
mk/list-transform-v3

Conversation

@mhk197

@mhk197 mhk197 commented Oct 14, 2026 •

Copy link
Copy Markdown
Contributor

Issue: #9249

Summary

list_transform(list, lambda) applies a one-parameter lambda to every element of a List (in any encoding), keeping each list's length and validity.

list_transform(col("l"), Lambda::new(["x"], checked_add(var("x"), lit(1_i32)))?)
// vortex.list.transform($.l, (x) -> (@x + 1i32))

Changes

  • ListTransform's options are ListTransformOptions: the lambda as written (Lambda) in an expression, or bound to the element's dtype (BoundLambda) in a bound expression or an array. Its only child is the list.
  • bind_options binds the lambda to the element's dtype when the node is bound, so execute only runs it. A FixedSizeList, or a lambda with anything but exactly one parameter, is rejected there and in return_dtype. A lambda already bound to another element dtype is an error.
  • The result keeps the input's shape: a ListArray stays a ListArray and anything else runs as a list view, with the same offsets (and sizes) and validity around the new elements.
  • A lambda that can fail runs only on the elements valid lists reference. One that can't runs on the whole elements buffer, cut to the lists' range for a ListArray.
  • The function is strict (a null list gives a null result), it can fail only if the lambda can, and it is not serializable yet.

Benchmark

100k lists, ~800k i32 elements, ~1/8 of the lists null where nullable, result exported to Arrow, median µs, local scratch divan benchmark against DataFusion 55.2's array_transform on the same lists:

Lists Lambda DataFusion This PR
Exact x + 1 49 71
Null lists already empty x + 1 60 73
Null lists holding elements x + 1 224 274
Null lists holding elements x = 3 249 81
Sliced x = 3 85 99
List view, back to back x + 1 903 75

Most of the x + 1 gap is Vortex's checked add against DataFusion's wrapping add; with x = 3 the two kernels are even. DataFusion casts a list view to a list before transforming it.

@mhk197 mhk197 added the changelog/feature A new feature label Oct 14, 2026
@codspeed

codspeed Bot commented Oct 14, 2026 •

Copy link
Copy Markdown

Merging this PR will regress 2 benchmarks

4 warnings, 1 note

⚠️ Walltime results may be unreliable

Some benchmarks used the Walltime instrument outside CodSpeed Macro Runners, which can make the results inconsistent.

Learn more about Macro Runners

See affected benchmarks

⚠️ Unable to measure the execution of 1 benchmark

This benchmark measured no execution time, so its result is not comparable and counts as unchanged. This usually happens when the compiler removes the code under test.

Prevent compiler optimizations

See affected benchmarks

⚠️ 23 benchmarks are dominated by system calls

System calls cannot be simulated consistently and are left out of the measure. The reported value understates the real cost. Switch to the Walltime instrument to measure them.

Learn more about system calls

See affected benchmarks

⚠️ Base and head ran on different runner types

Some benchmarks with significant performance changes were compared across different runner types, so the changes might not come from the code. Run base and head on the same runner type, or use CodSpeed Macro Runners for more stable results.

Learn more about Macro Runners

Open the report in CodSpeed to investigate

ℹ️ 40 benchmarks have no flamegraph

The flamegraph wasn't generated: the symbols of the profiling trace could not be resolved, because no information about the language frames was collected.

See affected benchmarks

⚡ 3 improved benchmarks
❌ 2 regressed benchmarks
✅ 2434 untouched benchmarks
⏩ 12 skipped benchmarks1

Warning

Please fix the performance issues or acknowledge them on CodSpeed.

Performance Changes

Mode Benchmark BASE HEAD Efficiency
❌ CPU Simulation test_repeated_scan_scalar_at[int_str_float] 476.6 µs 548.1 µs -13.05%
❌ CPU Simulation test_scan_filter[int] 3 ms 3.4 ms -11.45%
⚡ Walltime threshold_avx512[80] 2.8 µs 2.5 µs +11.66%
⚡ Walltime starts_with_avx512[8, VarBinView] 76.3 µs 68.4 µs +11.6%
⚡ CPU Simulation chunked_canonicalize_into[(20, 100, 16, 4)] 370.2 µs 336.5 µs +10.02%

Tip

Investigate this regression by commenting @codspeedbot fix this regression on this PR, or directly use the CodSpeed MCP with your agent.


Comparing mk/list-transform-v3 (c546c66) with develop (4c0e543)

Open in CodSpeed

Footnotes

  1. 12 benchmarks were skipped, so the baseline results were used instead. If they were deleted from the codebase, click here and archive them to remove them from the performance reports. ↩

@mhk197
mhk197 force-pushed the mk/list-transform-v3 branch from b532ee2 to 42ca3cd Compare October 19, 2026 14:13
@mhk197 mhk197 mentioned this pull request Oct 19, 2026
@mhk197
mhk197 force-pushed the mk/list-transform-v3 branch from 42ca3cd to fca3b22 Compare October 19, 2026 14:19
@mhk197
mhk197 added this pull request to stack #10614 October 19, 2026 14:32
@mhk197
mhk197 force-pushed the mk/list-transform-v3 branch 2 times, most recently from 6d06424 to b9493f8 Compare October 19, 2026 16:40
@mhk197
mhk197 force-pushed the mk/list-transform-v3 branch 3 times, most recently from 1c8172e to f687da3 Compare October 19, 2026 20:11
@mhk197
mhk197 force-pushed the mk/list-transform-v3 branch from f687da3 to 941e01d Compare October 20, 2026 18:45
@mhk197
mhk197 force-pushed the mk/list-transform-v3 branch from 941e01d to 01f5e6c Compare October 20, 2026 19:55
@mhk197
mhk197 force-pushed the mk/list-transform-v3 branch from 01f5e6c to c180f45 Compare October 21, 2026 15:30
@mhk197
mhk197 deployed to duckdb-build October 21, 2026 15:30 — with GitHub Actions Active
@mhk197
mhk197 requested a review from joseph-isaacs October 21, 2026 15:50
@mhk197
mhk197 marked this pull request as ready for review October 21, 2026 15:50
@mhk197
mhk197 force-pushed the mk/list-transform-v3 branch from c180f45 to b672f3c Compare October 21, 2026 18:22
@mhk197
mhk197 force-pushed the mk/list-transform-v3 branch from b672f3c to 83f89b4 Compare October 22, 2026 19:05
@mhk197
mhk197 force-pushed the mk/list-transform-v3 branch from 3de16b8 to ed77d49 Compare October 23, 2026 14:49
@mhk197
mhk197 removed this pull request from stack #10614 October 23, 2026 18:14
@mhk197
mhk197 force-pushed the mk/list-transform-v3 branch 2 times, most recently from 7ade901 to 898a14e Compare October 26, 2026 12:20
@mhk197
mhk197 changed the base branch from develop to mk/list-rebuild-referenced-valid-elements October 26, 2026 12:20
@mhk197
mhk197 added this pull request to stack #10797 October 26, 2026 12:23
@mhk197
mhk197 force-pushed the mk/list-transform-v3 branch 2 times, most recently from f6c682c to 45f4528 Compare October 26, 2026 14:31
@mhk197
mhk197 force-pushed the mk/list-transform-v3 branch 2 times, most recently from 0c48027 to 435655f Compare October 26, 2026 16:59
@mhk197
mhk197 force-pushed the mk/list-transform-v3 branch from 435655f to dc1a261 Compare October 26, 2026 18:03
Base automatically changed from mk/list-rebuild-referenced-valid-elements to develop October 27, 2026 10:44
mhk197 added a commit that referenced this pull request Oct 27, 2026
## Summary

Adds `ListArray::compact` and a list view rebuild mode,
`ListViewRebuildMode::MakeExactWithEmptyNulls`. Both return the same
lists over only the elements that valid lists reference: nothing before
the first list, after the last, between lists or under a null list.

A list column can hold elements that no valid list shows. Slicing keeps
the parent's elements on both sides, and a null list may still cover
elements. A function that only reads values does not care, but a
function that can fail on an element, such as a checked `x + 1`, must
not see them: an overflow in an element under a null list would fail the
whole batch. `list_transform` (#10503, stacked on this PR) is the first
user; it applies its lambda to the rebuilt elements whenever the lambda
can fail.

For example:

```text
elements  [9, 1, 2, 9, 9, 3]
offsets   [1, 3, 5, 6]
validity  [valid, null, valid]
lists     [1, 2], null over [9, 9], [3]

rebuilt
elements  [1, 2, 3]
offsets   [0, 2, 2, 3]
```

## How it works

**`ListArray`**

- Non-nullable or all-valid lists with nothing at the ends are returned
as they are, after reading two offsets.
- Otherwise it looks for null lists that hold elements: one pass marks
the lists whose next offset is larger, and one bit operation clears the
valid ones. Only when bits are left does it collect them into runs.
- With no such run, only the ends go: the elements are sliced to `[first
offset, last offset)` and the offsets are shifted down, without copying
an element.
- With some, the elements between those runs are kept, by a slice if one
range is left and otherwise by one filter whose mask carries the ranges,
so it copies range by range. The offsets are copied once and each one
moves down by the elements dropped before it, so the null lists in those
runs become empty.
- The offsets keep their type and the validity is unchanged.

**`ListViewRebuildMode::MakeExactWithEmptyNulls`**

- `MakeExact` only trims the ends of a view that is already zero-copy to
a list, so its null lists keep their elements. The new mode drops those
too.
- Such a view has its lists back to back, so it goes through the same
two steps on its offsets, with the last list's end as the final
boundary. It keeps its layout and its offsets' and sizes' types. Only
the sizes of the null lists that held elements are set to 0; otherwise
the sizes are kept as they are.
- Any other view is rebuilt with `MakeExact`, which already copies only
the valid lists' elements and leaves null lists empty. `MakeExact`'s
documentation now says both.
- The result is always zero-copy to a list.

## Benchmark

`list_transform` with a checked `x + 1` over 100k lists (~800k `i32`
elements, ~1/8 of the lists null), through #10503, exported to Arrow,
median µs over three alternating runs of a local divan benchmark.
"Before" is #10503 without this PR, which used the existing list view
rebuild instead.

| Lists | Before | This PR | DataFusion 54.1 `array_transform` |
|---|---|---|---|
| Null lists holding elements | 1,272 | 253 | 224 |
| Null lists already empty | 97 | 73 | 60 |
| Sliced, with elements on both sides | 78 | 81 | 55 |

## Testing

- `rstest` cases for both types: ends to drop, null lists holding
elements or not, every list null, no lists, an empty valid list between
null lists, overlapping and out-of-order views, views with and without
the zero-copy flag.
- Exact arrays come back as the same arrays (`ArrayRef::ptr_eq`), and a
sliced `ListArray` whose validity starts mid-byte is rebuilt correctly.
- A seeded random test compares `ListArray` and both kinds of view
against a reference that gathers each valid list's elements, over five
offset types, with non-nullable validity and validity arrays that start
mid-byte.

---------

Signed-off-by: Matt Katz <mhkatz97@gmail.com>
Signed-off-by: Matt Katz <mhkatz97@gmail.com>
Signed-off-by: Matt Katz <mhkatz97@gmail.com>
…elements

Signed-off-by: Matt Katz <mhkatz97@gmail.com>
Signed-off-by: Matt Katz <mhkatz97@gmail.com>
…Lists enum

Signed-off-by: Matt Katz <mhkatz97@gmail.com>
Signed-off-by: Matt Katz <mhkatz97@gmail.com>
Signed-off-by: Matt Katz <mhkatz97@gmail.com>
@mhk197
mhk197 force-pushed the mk/list-transform-v3 branch from dc1a261 to c546c66 Compare October 27, 2026 10:44
@mhk197
mhk197 merged commit f62c12f into develop Oct 27, 2026
83 of 84 checks passed
@mhk197
mhk197 deleted the mk/list-transform-v3 branch October 27, 2026 11:13
ChristopherWirt added a commit to quantcast/vortex that referenced this pull request Oct 28, 2026
## Summary

Closes #9

Spark asks the scan for only the nested fields a query uses, but the
connector read every nested column whole. `SELECT s.b` decoded all of
`s`.

Struct columns are now pruned all the way down to the native scan, at
any depth. Fields inside arrays and maps are still read whole; that
needs `list_transform`
([vortex-data#10503](vortex-data#10503)).

## Changes

**Spark connector**
- `pruneColumns` keeps Spark's pruned type for struct columns.
- `NestedSchemaPruning` turns the pruned read schema into a native
projection:
- `select([b], $.s)` when only leaf fields are pruned. This keeps the
struct's own nulls.
- `mask(pack(..), is_not_null($.s))` when a child struct is pruned too.
`pack` has no nulls, so the mask carries them over. Non-nullable structs
skip the mask.

**JNI**
- Adds an `Expression.mask` binding.

**`StructReader`**
- No changes. Upstream
[vortex-data#10536](vortex-data#10536)
collapses single-field partitions, so the Spark projections are read
field by field without a special case. An earlier version of this PR
extended `StructReader`'s direct projection path, which vortex-data#10536 removed.

**Tests**
- Spark: null structs, null leaves, nulls at each level of a struct
inside a struct, and a pushed filter on a field that was pruned away.
- Layout: segment-count tests, now run against vortex-data#10536's partitioned
path. A pruned nullable struct column reads only its kept fields plus
its validity.
- `CountingSegmentSource` now lives in `segments/test.rs`, replacing
three copies.

## Benchmark

Measured before the merge of vortex-data#10536. JMH, 2 forks × 10 iterations,
release native library, Spark 4.1 local[4]. The table has 2M rows, with
a nullable 20-field struct `s` and the same struct nested at
`outer.inner`. `nestedSchemaPruning=false` is the old behaviour, where
the whole struct is read.

| Query | Pruning on | Pruning off |
|---|---|---|
| `max(s.f3)` | 26.2 ± 2.3 ms | 40.2 ± 3.4 ms |
| `max(outer.inner.f3)` | 22.0 ± 1.1 ms | 37.1 ± 2.3 ms |

## Checks

- `cargo test -p vortex-layout --lib`: 696 passed
- `cargo clippy -p vortex-layout --all-targets --all-features -- -D
warnings`: clean
- `cargo +nightly-2026-09-10 fmt --check -p vortex-layout -p
vortex-jni`: clean
- `./gradlew spotlessCheck :vortex-jni:test :vortex-spark_2.13:test
:vortex-spark_2.12:test`: all pass (JNI 97, Spark 2.13 225, Spark 2.12
225 with 2 Spark-4-only tests skipped).
- Not run since vortex-data#10536: the benchmark, the full workspace suite.

## Known gaps

- `Expression` factories don't keep their inputs reachable while the
native call runs. In theory GC can free a temporary first. This affects
every factory, not just the new `mask`, so it's left for a separate fix.
- `count(*)`, or a query that selects only partition columns, still
reads every column. This predates the PR.
- Pruning two levels inside a nullable struct reads the whole inner
struct. For `nullable_outer.inner.c`, `inner.a` and `inner.b` are still
read; sibling fields of `inner` are skipped. The cause is upstream:
`StructReader` masks `$.inner` with the parent's validity before the
`select`, so the `select` can't be pushed down into `inner`.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Signed-off-by: Christopher Wirt <cwirt@quantcast.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

changelog/feature A new feature

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants